Recomposition 최적화 - 파라미터의 Stability
* Compose phases 에 대해서(특히 Composition phase) 어느정도 알고있다는 가정하에 작성하였습니다.
1. Recomposition의 트리거
Recomposition is typically triggered by a change to a
State<T>
object. Compose tracks these and runs all composables in the Composition that read that particularState<T>
, and any composables that they call that cannot be skipped.
Recomposition은 State 객체에 변경이 발생하면 실행된다.
State 객체에만 변경이 발생하면 Recomposition이 일어날까?
@Composable
fun RecompositionScreen() {
var count by remember { mutableIntStateOf(0) }
SideEffect {
println("Recomposition - ${count}")
}
Column {
Button(
onClick = {
count+=1
},
content = {
Text(
text = "Click"
)
}
)
}
}
Recomposition이 발생했다면, println("Recomposition - ${count}")
부분이 출력되어야 한다. 하지만 이 부분은 출력되지 않는다. 그렇다면 Recomposition은 발생하지 않는다는 의미이다. 왜? => count state를 읽는 composable이 없기 때문이다.
결국에 Recomposition이 발생하는 조건은
- State 객체가 변경되었을 때
- 그리고 그것을 읽는 Composable이 존재할 때
이다.
@Composable
fun RecompositionScreen() {
var count by remember { mutableIntStateOf(0) }
SideEffect {
println("Recomposition - ${count}")
}
Column {
Button(
onClick = {
count+=1
},
content = {
Text(
text = "Click: $count" // 이라면 Recomposition이 발생하게된다.
)
}
)
}
}
1-1. 변경된 State를 읽는 Composable에서만 Recomposition이 발생해?
그렇진 않다.
일단 Recomposition이 발생하면 Recomposition 루트 노드에 이어진 모든 노드들은 recomposition 평가 대상이 된다.
@Composable
fun RecompositionScreen() {
var info by remember { mutableStateOf(Info("title-2")) }
var count by remember { mutableIntStateOf(0) }
SideEffect {
println("RecompositionScreen Recomposition - ${count}")
}
Column {
RecompositionScreenDetail(info)
Button(
onClick = {
count+=1
},
content = {
Text(
text = "Click"
)
}
)
Text(
text = "count: $count"
)
HorizontalDivider(thickness = 2.dp)
}
}
@Composable
fun RecompositionScreenDetail(
info: Info
) {
SideEffect {
println("RecompositionScreenDetail Recomposition - ${info.hashCode()}")
}
Text(
text = "RecompositionScreenDetail: ${info.title}"
)
}
data class Info(
var title: String
)
위의 코드에서 RecompositionScreenDetail() composable 함수도 recomposition이 된다.
이 메서드는 count를 읽지도 않는데? 이것이 파라미터의 Stability를 신경써야 하는 이유이다.
1-2. Recomposition을 발생시키는 내부요인, 외부요인
State 객체를 가지고있는 Composable은 Recomposition을 발생시키는 '내부요인'을 가지고 있는 것이다. 그리고 그 Composable로부터 파라미터를 통해 해당 State 객체를 넘겨받는 Composable은 Recomposition을 발생시키는 '외부요인'을 가지고 있다.
참고 - Tips. Compose stable, unstable 상태와 자주 사용하는 어노테이션 정리, Nanamare
2. Recomposition을 Skip할 수 있거나, Skip할 수 없거나
외부요인, 즉 파라미터를 가진 Composable이 Skippable하려면 그 파라미터가 어떤 조건을 갖추어야 할까? 기본적인 조건은 안드로이드 문서에 설명이 되어있다.
- All primitive value types:
Boolean
,Int
,Long
,Float
,Char
, etc.- Strings
- All function types (lambdas)
Android Doc - Lifecycle of Composables
위의 타입들이 왜 Skippable한지에 대한 이유도 설명해주고 있다. 가장 핵심적인 이유는 이 타입들이 파라미터로 정의된다면 Immutable하기 때문에 그 자체로 Stable하다고 할 수 있다는 것이다.
Compose 컴파일러는 컴파일시에 Compose함수가 Skippable한지에 대해 평가한다. Skippable한지를 평가하는 조건은 타입이 Stable한가? 이며 타입이 Stable하다는 것은 Compose 런타임에서 변화가 일어났을 때에 알아차릴 수 있는가? 이다.
위의 말을 다시 반복하자면, Compose 런타임은 Stable한 타입의 변화를 감지할 수 있기 때문에 변화가 없는경우 굳이 Recomposition을 실행할 필요가 없어진다. 그러나 Stable 하지 않은 타입은 Compose가 변화를 제대로 감지하리라는 보장을 해줄 수 없기 때문에 Recomposition에 대한 책임을 질 수 없어 Recomposition을 할 수 있을 때마다 해주는게 자연스러운 작업이라고 생각할 수 있다.
2-1. 다른 타입은?
primitive가 아니라 reference라면? Interface는?
내가 recomposition을 명확하게 이해하기 위해 시간 할애를 가장 많이 한 부분이였다.
Primitive타입이 아니더라도 Stable을 보장할 수 있다면 Skippable할 수 있다.
Stable을 보장하는 방법은
- public 변수가 Immutable한 경우
- Stable 애노테이션 추가
- Immutable 애노테이션 추가
이다.
몇가지 헷갈릴 수 있는 경우를 살펴보자.
List같은 Collection 타입은? val로만 설정해주면 Stable 한 것일까?
아니다. 이것은 인터페이스로 이 변수에 실제로 할당되는 인스턴스는 ImmutableList 일수도, MutableList일수도 있다. 이렇게 stable인지 아닌지 구분할 수 없는 인터페이스는 unstable로 간주된다.
List를 가지고 있는 클래스에 Stable 애노테이션을 추가하면?
아래와 같은 경우 Stable로 간주한다. 그러나 fruits 인스턴스에 add(), remove() 메소드를 호출한다고 하더라도 변화가 감지되지 않는다는 것을 주의해야 한다.
@Stable
data class Info(
val fruits: List<String>
)
참조형 타입을 가지고있는 참조형 타입은?
아래와 같은 경우로, 가지고 있는 참조형 타입의 변수가 immutable하며 primitive하다. 이 경우에 Compose는 Info 타입을 Stable한 타입으로 간주할 수 있게된다.
Note: All deeply immutable types can safely be considered stable types.
Android Doc - Lifecycle of Composables
data class Info(
val infoExra: InfoExtra
)
data class InfoExtra(
val titleExtra: String
)
*그러나 titleExtra 변수가 mutable하게 선언되어 있다거나, 인터페이스 타입의 변수라면 Compose는 이를 Stable한 타입으로 간주할 수 없게 된다.
2-2. Data Class와 General Class의 Stable
Stability를 공부하면서 Data Class와 General Class의 차이를 실감하게 되었다.
그동안은 copy(), toString()등의 메소드 사용과 destructuring를 위해서 data class를 사용하였는데, 이것이 persistent쪽으로도 관련이 있을 수 있다는걸 체감하게 된 것이다.
아래의 두 클래스는 data 키워드가 있냐, 없냐의 차이이다.
data class DataInfo(
val title: String
)
class Info(
val title: String
)
편의 메서드와 분해 이외에 제일 중요한게 있다면 equals(), hashcode() 일 것이다.
아래의 테스트를 보면 dataInfo1과 2는 서로 다른 인스턴스임에도 assertEquals를 통과한다.
@Test
fun dataClass_generalClass_equals() {
val dataInfo1 = DataInfo("DataInfo")
val dataInfo2 = DataInfo("DataInfo")
val info1 = Info("Info")
val info2 = Info("Info")
assertEquals(dataInfo1, dataInfo2)
assertNotEquals(info1, info2)
}
이것은 Data Class가 equals()와 hashcode()메서드를 overriding하기 때문인데 자세한 것은 이 포스트의 범위를 넘어가니 참고 포스트로 대신한다. 간단히 말해서 data class는 같은 타입의 인스턴스에 대해 equals를 판단할 때 메모리의 주소가 아니라 객체가 가지고 있는 변수의 값을 비교한다. 그 값이 같다면 hashcode도 같은 값을 리턴한다. (general class는 메모리의 주소로 equals를 판단한다.) 참고 포스트 - 코틀린 data class에서 자동으로 처리하는 equals와 hashCode를 알아보자., Taehwan
그렇다면 이것이 Compose의 Stability랑 어떤 관계가 있을까?
Compose는 타입이 Stable하다면 Recomposition 여부를 체크할 때 equals()메서드의 반환값을 가지고 사용한다. 즉, Data Class로 선언한 타입이라면 새로운 인스턴스를 생성해서 할당해줬음에도 Recomposition이 일어나지 않을 수 있다는 것이다. 반면 Class로 선언한 타입의 경우 같은 값을 가진 인스턴스를 새로 생성해서 할당해줬다면 Recomposition이 일어나게 된다. 이 차이를 알고 사용하는것이 중요하다.
2-2. 타입들에 대한 테스트
내가 헷갈릴만한 거의 모든 타입에 대한 테스트를 해보게 되었다. 이를 정리해본다.
// 1번
data class Info(
val title: String
)
// 2번
data class Info(
var title: String
)
// 3번
class Info(
val title: String
)
// 4번
class Info(
var title: String
)
// 5번
@Stable
data class Info(
var title: String
)
// 6번
@Stable
class Info(
var title: String
)
// 7번
class Info(
var title: MutableState<String> = mutableStateOf("")
)
// 8번
data class Info(
val fruits: List<String>
)
// 9번
@Stable
data class Info(
val fruits: List<String>
)
// 10번
data class Info(
val infoExra: InfoExtra
)
data class InfoExtra(
val titleExtra: String
)
/*
- 1번을 파라미터로 받는 경우에는 Info 객체가 변하지 않으면 recomposition은 일어나지 않는다.
- 2번을 파라미터로 받는 경우에는 항상 recomposition이 일어난다.
- 3번을 파라미터로 받는 경우에는 Info 객체가 변하지 않으면 recomposition은 일어나지 않는다.
- 4번을 파라미터로 받는 경우에는 항상 recomposition이 일어난다.
- 5번을 파라미터로 받는 경우에는 Info 객체가 변하지 않으면 recomposition은 일어나지 않는다.
- 6번을 파라미터로 받는 경우에는 Info 객체가 변하지 않으면 recomposition은 일어나지 않는다.
- 7번을 파라미터로 받는 경우에는 항상 recomposition이 일어난다.
- 8번을 파라미터로 받는 경우에는 항상 recomposition이 일어난다.
- 9번을 파라미터로 받는 경우에는 Info 객체가 변하지 않으면 recomposition은 일어나지 않는다.
- 10번을 파라미터로 받는 경우에는 Info 객체가 변하지 않으면 recomposition은 일어나지 않는다.
*/
3. 참고 자료
- State가 하는일과 Remember, RememberSaveable이 하는 일을 구분해서 생각할 때 본 글
Compose의 Remember, RememberSaveable 정복하기, 배준형 - Smart Recomposition에 대한 이해, 애노테이션 상세설명, 컴포즈 컴파일러 매트릭에 대한 설명이 좋은 글
Jetpack Compose 성능 최적화를 위한 Stability 이해하기, skydoves - Compose의 메모리 구조(Gap Buffer), Recompose Trigger 과정 이해하기
Understanding Jetpack Compose: Internal Implementation and Working, Sagar Malhotra - Compose 런타임시에 Observe할 수 있게 만드는 커스텀 State만들기, Snapshot System에 대한 글
Jetpack Compose 나만의 State 만들기, Ji Sungbin